From 0f96bafbf4adb36bdcb85ca7d3440c0706ff2028 Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Fri, 3 Jul 2026 10:31:13 +0200 Subject: [PATCH 1/2] Run prettier on the release workflow test Reformat tests/workflows/release.spec.ts (added in ARSN-604) to satisfy the prettier check, in a standalone commit so the functional ARSN-605 change stays prettier-clean. Issue: ARSN-605 --- tests/workflows/release.spec.ts | 93 +++++++++++++++++---------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/tests/workflows/release.spec.ts b/tests/workflows/release.spec.ts index ce6ecb9f8..741a7ca28 100644 --- a/tests/workflows/release.spec.ts +++ b/tests/workflows/release.spec.ts @@ -1,52 +1,55 @@ -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-latest', 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" }, + { name: 'Fail if release already exists', mockWith: 'echo no-release-found' }, + { 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 +60,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); }); From 54cde5f07cb3bfd03149b4f14fff9be0b6a23d90 Mon Sep 17 00:00:00 2001 From: Thomas Flament Date: Fri, 3 Jul 2026 10:31:13 +0200 Subject: [PATCH 2/2] Publish arsenal to GitHub Packages and npm on release Extend the manual release workflow (ARSN-604) to build and dual-publish @scality/arsenal (a public, Apache-2.0 library) to GitHub Packages and npmjs, split into jobs so the build is produced once and each publish can be retried independently of it and of the other: - build: after the branch/version/tag guards, install, build, pack the tarball, generate a signed build-provenance attestation for it (actions/attest-build-provenance), and upload it as the artifact. - publish-github: download the tarball and publish it to GitHub Packages with the built-in GITHUB_TOKEN. - publish-npm: publish the same tarball to npmjs via OIDC trusted publishing (id-token, no stored token) with --provenance. - release: create the GitHub release, linking to both packages. Both publish jobs use the committed package.json version (no auto-bump), check the registry with npm view first and skip if that version is already published (so a partial dual-publish can be re-run cleanly), and publish under a v dist-tag so an older release line never moves the latest tag. Building and packing once, then attesting and publishing the same tarball to both registries, yields a single GitHub attestation covering whatever lands in either registry, plus npm-native provenance on npmjs. package.json is renamed to @scality/arsenal, drops the private flag, and gains types, files and publishConfig.access: public. The scoped name is committed directly rather than stamped at publish time: git-dependency consumers resolve arsenal by their dependency key, not the package name, so committing @scality/arsenal keeps require('arsenal') working for the current git consumers (verified) while making the published package correctly scoped. Token permissions are scoped per job. The workflow tests stub the publish path so the guard coverage is unchanged. Issue: ARSN-605 --- .github/workflows/release.yaml | 113 ++++++++++++++++++++++++++++++-- package.json | 10 ++- tests/workflows/release.spec.ts | 22 ++++++- 3 files changed, 134 insertions(+), 11 deletions(-) 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 741a7ca28..81400ae95 100644 --- a/tests/workflows/release.spec.ts +++ b/tests/workflows/release.spec.ts @@ -31,7 +31,7 @@ async function setup(versionFixture: string, branch: string): Promise { await github.setup(); const act = new Act(github.repo.getPath('arsenal')); act.setWorkflowFile('.github/workflows/release.yaml'); - act.setPlatforms('ubuntu-latest', IMAGE); + act.setPlatforms('ubuntu-22.04', IMAGE); act.setEnv('GITHUB_REPOSITORY', 'scality/Arsenal'); act.setEnv('GITHUB_REF', `refs/heads/${branch}`); return act; @@ -42,10 +42,26 @@ async function createTag(tag: string) { } const mockSteps = { - release: [ + build: [ { name: 'Fail if release already exists', mockWith: 'echo no-release-found' }, - { name: 'Create Release', mockWith: 'echo skip-create-release' }, + { 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) {