diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cec1a39f7..b04d1b3cb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -5,12 +5,17 @@ run-name: release ${{ github.ref_name }} on: workflow_dispatch: -permissions: - contents: write +permissions: {} jobs: - release: - runs-on: ubuntu-latest + build: + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + attestations: write + outputs: + version: ${{ steps.version.outputs.version }} steps: - name: Reject disallowed branch if: >- @@ -60,12 +65,108 @@ jobs: exit 1 fi + - name: Install NodeJS + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile --prefer-offline + + - name: Build + run: yarn build + + - name: Pack tarball + run: npm pack --ignore-scripts + + - name: Attest build provenance + uses: actions/attest-build-provenance@v4 + with: + subject-path: '*.tgz' + + - name: Upload package artifact + uses: actions/upload-artifact@v7 + with: + name: package + path: '*.tgz' + if-no-files-found: error + retention-days: 7 + + publish-github: + needs: build + runs-on: ubuntu-22.04 + permissions: + packages: write + steps: + - name: Download package artifact + uses: actions/download-artifact@v8 + with: + name: package + + - name: Install NodeJS + uses: actions/setup-node@v6 + with: + node-version: '24' + registry-url: 'https://npm.pkg.github.com' + scope: '@scality' + + - name: Publish to GitHub Packages + env: + NODE_AUTH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + run: | + if [ -n "$(npm view "@scality/arsenal@$VERSION" version 2>/dev/null)" ]; then + echo "::notice::@scality/arsenal@$VERSION already on GitHub Packages; skipping" + else + npm publish *.tgz --tag "v${VERSION%.*}" + fi + + publish-npm: + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + steps: + - name: Download package artifact + uses: actions/download-artifact@v8 + with: + name: package + + - name: Install NodeJS + uses: actions/setup-node@v6 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + scope: '@scality' + + - name: Publish to npm + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + if [ -n "$(npm view "@scality/arsenal@$VERSION" version 2>/dev/null)" ]; then + echo "::notice::@scality/arsenal@$VERSION already on npm; skipping" + else + npm publish *.tgz --provenance --tag "v${VERSION%.*}" + fi + + release: + needs: [build, publish-github, publish-npm] + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: - name: Create Release uses: softprops/action-gh-release@v3 env: GITHUB_TOKEN: ${{ github.token }} with: - name: ${{ steps.version.outputs.version }} - tag_name: ${{ steps.version.outputs.version }} + name: ${{ needs.build.outputs.version }} + tag_name: ${{ needs.build.outputs.version }} generate_release_notes: true + append_body: true + body: | + GitHub Packages: https://github.com/${{ github.repository }}/pkgs/npm/arsenal + npm: https://www.npmjs.com/package/@scality/arsenal target_commitish: ${{ github.sha }} diff --git a/package.json b/package.json index cfeb6eb17..39c0a21b3 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "arsenal", + "name": "@scality/arsenal", "engines": { "node": ">=20" }, @@ -11,6 +11,13 @@ }, "description": "Common utilities for the S3 project components", "main": "build/index.js", + "types": "build/index.d.ts", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/scality/Arsenal.git" @@ -114,7 +121,6 @@ "prepare": "yarn build", "test": "export NODE_OPTIONS=\"--tls-max-v1.2\" && jest tests/unit --detectOpenHandles" }, - "private": true, "jest": { "maxWorkers": 1, "coverageReporters": [ diff --git a/tests/workflows/release.spec.ts b/tests/workflows/release.spec.ts index ce6ecb9f8..81400ae95 100644 --- a/tests/workflows/release.spec.ts +++ b/tests/workflows/release.spec.ts @@ -1,52 +1,71 @@ -import { Act } from "@kie/act-js"; -import { MockGithub } from "@kie/mock-github"; -import path from "path"; -import { exec as execCb } from "node:child_process"; -import { promisify } from "node:util"; -import { mkdtempSync } from "node:fs"; -import os from "node:os"; +import { Act } from '@kie/act-js'; +import { MockGithub } from '@kie/mock-github'; +import path from 'path'; +import { exec as execCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync } from 'node:fs'; +import os from 'node:os'; const exec = promisify(execCb); -const IMAGE = "ghcr.io/catthehacker/ubuntu:act-latest"; +const IMAGE = 'ghcr.io/catthehacker/ubuntu:act-latest'; let github: MockGithub; async function setup(versionFixture: string, branch: string): Promise { // unique setup path per test so act's checkout residue can't collide with another test's repo - const setupPath = mkdtempSync(path.join(os.tmpdir(), "arsenal-workflows-")); - github = new MockGithub({ - repo: { - arsenal: { - currentBranch: branch, - files: [ - { src: path.resolve(__dirname, "../..", ".github"), dest: ".github" }, - { src: path.resolve(__dirname, "fixtures", versionFixture), dest: "package.json" }, - ], + const setupPath = mkdtempSync(path.join(os.tmpdir(), 'arsenal-workflows-')); + github = new MockGithub( + { + repo: { + arsenal: { + currentBranch: branch, + files: [ + { src: path.resolve(__dirname, '../..', '.github'), dest: '.github' }, + { src: path.resolve(__dirname, 'fixtures', versionFixture), dest: 'package.json' }, + ], + }, }, }, - }, setupPath); + setupPath, + ); await github.setup(); - const act = new Act(github.repo.getPath("arsenal")); - act.setWorkflowFile(".github/workflows/release.yaml"); - act.setPlatforms("ubuntu-latest", IMAGE); - act.setEnv("GITHUB_REPOSITORY", "scality/Arsenal"); - act.setEnv("GITHUB_REF", `refs/heads/${branch}`); + const act = new Act(github.repo.getPath('arsenal')); + act.setWorkflowFile('.github/workflows/release.yaml'); + act.setPlatforms('ubuntu-22.04', IMAGE); + act.setEnv('GITHUB_REPOSITORY', 'scality/Arsenal'); + act.setEnv('GITHUB_REF', `refs/heads/${branch}`); return act; } async function createTag(tag: string) { - await exec(`git -C ${github.repo.getPath("arsenal")} tag --no-sign -m test ${tag}`); + await exec(`git -C ${github.repo.getPath('arsenal')} tag --no-sign -m test ${tag}`); } const mockSteps = { - release: [ - { name: "Fail if release already exists", mockWith: "echo no-release-found" }, - { name: "Create Release", mockWith: "echo skip-create-release" }, + build: [ + { name: 'Fail if release already exists', mockWith: 'echo no-release-found' }, + { name: 'Install NodeJS', mockWith: 'echo skip-setup-node' }, + { name: 'Install dependencies', mockWith: 'echo skip-install' }, + { name: 'Build', mockWith: 'echo skip-build' }, + { name: 'Pack tarball', mockWith: 'echo skip-pack' }, + { name: 'Attest build provenance', mockWith: 'echo skip-attest' }, + { name: 'Upload package artifact', mockWith: 'echo skip-upload' }, ], + 'publish-github': [ + { name: 'Download package artifact', mockWith: 'echo skip-download' }, + { name: 'Install NodeJS', mockWith: 'echo skip-setup-node' }, + { name: 'Publish to GitHub Packages', mockWith: 'echo skip-publish' }, + ], + 'publish-npm': [ + { name: 'Download package artifact', mockWith: 'echo skip-download' }, + { name: 'Install NodeJS', mockWith: 'echo skip-setup-node' }, + { name: 'Publish to npm', mockWith: 'echo skip-publish' }, + ], + release: [{ name: 'Create Release', mockWith: 'echo skip-create-release' }], }; function run(act: Act) { - return act.runEvent("workflow_dispatch", { mockSteps }); + return act.runEvent('workflow_dispatch', { mockSteps }); } function step(result: { name: string; status: number }[], name: string) { @@ -57,37 +76,37 @@ afterEach(async () => { await github.teardown(); }); -test("rejects a release dispatched from a disallowed branch", async () => { - const act = await setup("package-versioned.json", "feature/not-a-release-branch"); +test('rejects a release dispatched from a disallowed branch', async () => { + const act = await setup('package-versioned.json', 'feature/not-a-release-branch'); const result = await run(act); - expect(step(result, "Reject disallowed branch")?.status).toBe(1); + expect(step(result, 'Reject disallowed branch')?.status).toBe(1); }); -test("allows a development/* branch and runs through to the release step", async () => { - const act = await setup("package-versioned.json", "development/8.3"); +test('allows a development/* branch and runs through to the release step', async () => { + const act = await setup('package-versioned.json', 'development/8.3'); const result = await run(act); // the branch guard is `if`-gated, so it is skipped (absent) on an allowed branch - expect(step(result, "Reject disallowed branch")).toBeUndefined(); + expect(step(result, 'Reject disallowed branch')).toBeUndefined(); expect(result.every(r => r.status === 0)).toBe(true); - expect(step(result, "Create Release")?.status).toBe(0); + expect(step(result, 'Create Release')?.status).toBe(0); }); -test("allows a hotfix/* branch", async () => { - const act = await setup("package-versioned.json", "hotfix/8.3.1"); +test('allows a hotfix/* branch', async () => { + const act = await setup('package-versioned.json', 'hotfix/8.3.1'); const result = await run(act); - expect(step(result, "Reject disallowed branch")).toBeUndefined(); - expect(step(result, "Create Release")?.status).toBe(0); + expect(step(result, 'Reject disallowed branch')).toBeUndefined(); + expect(step(result, 'Create Release')?.status).toBe(0); }); -test("fails when package.json has no version", async () => { - const act = await setup("package-no-version.json", "development/8.3"); +test('fails when package.json has no version', async () => { + const act = await setup('package-no-version.json', 'development/8.3'); const result = await run(act); - expect(step(result, "Read version from package.json")?.status).toBe(1); + expect(step(result, 'Read version from package.json')?.status).toBe(1); }); -test("fails when the tag already exists", async () => { - const act = await setup("package-versioned.json", "development/8.3"); - await createTag("8.3.12"); +test('fails when the tag already exists', async () => { + const act = await setup('package-versioned.json', 'development/8.3'); + await createTag('8.3.12'); const result = await run(act); - expect(step(result, "Fail if tag already exists")?.status).toBe(1); + expect(step(result, 'Fail if tag already exists')?.status).toBe(1); });