From 3e0ac9cccdafb2225b16630effc1980054e3245c Mon Sep 17 00:00:00 2001 From: Emilien Escalle Date: Thu, 5 Nov 2026 21:33:24 +0100 Subject: [PATCH] feat(release): share artifact and tag verification --- .github/workflows/__shared-ci.yml | 6 + .../__test-action-release-verify.yml | 59 ++++++ README.md | 4 + actions/release/verify/README.md | 55 +++++ actions/release/verify/action.yml | 41 ++++ actions/release/verify/index.js | 117 +++++++++++ actions/release/verify/index.test.js | 189 ++++++++++++++++++ 7 files changed, 471 insertions(+) create mode 100644 .github/workflows/__test-action-release-verify.yml create mode 100644 actions/release/verify/README.md create mode 100644 actions/release/verify/action.yml create mode 100644 actions/release/verify/index.js create mode 100644 actions/release/verify/index.test.js diff --git a/.github/workflows/__shared-ci.yml b/.github/workflows/__shared-ci.yml index 704bd910..9ead4795 100644 --- a/.github/workflows/__shared-ci.yml +++ b/.github/workflows/__shared-ci.yml @@ -11,6 +11,12 @@ on: # yamllint disable-line rule:truthy permissions: {} jobs: + test-action-release-verify: + uses: $/.github/workflows/__test-action-release-verify.yml + permissions: + actions: read + contents: read + linter: uses: hoverkraft-tech/ci-github-common/.github/workflows/linter.yml@44f6108cef0e9884ea7735f3d62932c2310745d4 # 0.39.1 permissions: diff --git a/.github/workflows/__test-action-release-verify.yml b/.github/workflows/__test-action-release-verify.yml new file mode 100644 index 00000000..adc4db4d --- /dev/null +++ b/.github/workflows/__test-action-release-verify.yml @@ -0,0 +1,59 @@ +name: Internal - Test release source verification +on: + workflow_call: +permissions: {} +jobs: + tests: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test artifact identity, tag conflicts and release recovery + run: node --test actions/release/verify/index.test.js + - name: Arrange - Source artifact + id: artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-verification-${{ github.run_id }}-${{ github.run_attempt }} + path: actions/release/verify/action.yml + if-no-files-found: error + - name: Act - Verify workflow artifact identity without creating a tag + id: verify + continue-on-error: ${{ github.event_name == 'pull_request' }} + uses: $/actions/release/verify + with: + artifact-ids: '["${{ steps.artifact.outputs.artifact-id }}"]' + tags: '["verify-${{ github.run_id }}-${{ github.run_attempt }}"]' + - name: Assert - Release identity follows the workflow source commit + env: + RELEASES: ${{ steps.verify.outputs.releases }} + OUTCOME: ${{ steps.verify.outcome }} + EXPECT_MISMATCH: ${{ github.event_name == 'pull_request' && github.sha != github.event.pull_request.head.sha }} + run: | + node <<'NODE' + const assert = require('node:assert/strict'); + // PR artifacts identify the head commit, while github.sha identifies the test merge. + if (process.env.EXPECT_MISMATCH === 'true') assert.equal(process.env.OUTCOME, 'failure'); + else { + assert.equal(process.env.OUTCOME, 'success'); + assert.equal(JSON.parse(process.env.RELEASES)[0].published, false); + } + NODE + - name: Act - Require a missing prepared tag + if: github.event_name != 'pull_request' + id: missing-tag + continue-on-error: true + uses: $/actions/release/verify + with: + artifact-ids: '["${{ steps.artifact.outputs.artifact-id }}"]' + tags: '["verify-${{ github.run_id }}-${{ github.run_attempt }}"]' + require-tags: "true" + - name: Assert - Missing tag fails verification + if: github.event_name != 'pull_request' + env: + OUTCOME: ${{ steps.missing-tag.outcome }} + run: test "$OUTCOME" = failure diff --git a/README.md b/README.md index b6a5eff8..d5bd71b1 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ _Actions for managing deployments._ ### Release +#### - [Verify release sources](actions/release/verify/README.md) + +Check artifact ownership, tag identity, and published state before publication. + _Actions for managing releases._ #### - [Create or refresh release](actions/release/create/README.md) diff --git a/actions/release/verify/README.md b/actions/release/verify/README.md new file mode 100644 index 00000000..7b7cd3da --- /dev/null +++ b/actions/release/verify/README.md @@ -0,0 +1,55 @@ + + +# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItc2hpZWxkIiBjb2xvcj0iYmx1ZSI+PHBhdGggZD0iTTEyIDIyczgtNCA4LTEwVjVsLTgtMy04IDN2N2MwIDYgOCAxMCA4IDEweiI+PC9wYXRoPjwvc3ZnPg==) GitHub Action: Verify release sources + + + + +## Overview + +Verify that release artifacts belong to this run and commit, reject conflicting tags, and identify already published releases without changing repository state. + + + + +## Usage + +```yaml +- uses: hoverkraft-tech/ci-github-publish/actions/release/verify@ + with: + # JSON array of validated artifact IDs from this workflow run and source commit. Require successful producer jobs with needs; artifact metadata does not prove validation passed. + # This input is required. + artifact-ids: "" + + # JSON array of release tags. Existing lightweight or annotated tags must resolve to this workflow's source commit. Package-specific version validation belongs to the caller. + # This input is required. + tags: "" + + # Require every tag to exist already, for releases prepared before validation. Leave false when publication creates new source tags. + # Default: `false` + require-tags: "false" +``` + + + + +## Inputs + +| **Input** | **Description** | **Required** | **Default** | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | +| **`artifact-ids`** | JSON array of validated artifact IDs from this workflow run and source commit. Require successful producer jobs with needs; artifact metadata does not prove validation passed. | **true** | - | +| **`tags`** | JSON array of release tags. Existing lightweight or annotated tags must resolve to this workflow's source commit. Package-specific version validation belongs to the caller. | **true** | - | +| **`require-tags`** | Require every tag to exist already, for releases prepared before validation. Leave false when publication creates new source tags. | **false** | `false` | + + + + + + +## Outputs + +| **Output** | **Description** | +| -------------- | ----------------------------------------------------------------------------------------------------------- | +| **`releases`** | JSON array of objects containing tag and published. A matching draft or absent release has published=false. | + + diff --git a/actions/release/verify/action.yml b/actions/release/verify/action.yml new file mode 100644 index 00000000..2768605c --- /dev/null +++ b/actions/release/verify/action.yml @@ -0,0 +1,41 @@ +name: Verify release sources +description: Verify that release artifacts belong to this run and commit, reject conflicting tags, and identify already published releases without changing repository state. +author: hoverkraft +branding: + icon: shield + color: blue +inputs: + artifact-ids: + description: JSON array of validated artifact IDs from this workflow run and source commit. Require successful producer jobs with needs; artifact metadata does not prove validation passed. + required: true + tags: + description: JSON array of release tags. Existing lightweight or annotated tags must resolve to this workflow's source commit. Package-specific version validation belongs to the caller. + required: true + require-tags: + description: Require every tag to exist already, for releases prepared before validation. Leave false when publication creates new source tags. + default: "false" +outputs: + releases: + description: JSON array of objects containing tag and published. A matching draft or absent release has published=false. + value: ${{ steps.verify.outputs.releases }} +runs: + using: composite + steps: + - id: verify + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTION_PATH: ${{ github.action_path }} + ARTIFACT_IDS: ${{ inputs.artifact-ids }} + TAGS: ${{ inputs.tags }} + REQUIRE_TAGS: ${{ inputs.require-tags }} + with: + script: | + const { ReleaseVerification } = require(process.env.ACTION_PATH + '/index.js'); + if (!['true', 'false'].includes(process.env.REQUIRE_TAGS)) throw new Error('require-tags must be true or false.'); + const verification = new ReleaseVerification({ github, context }); + const releases = await verification.verify({ + artifactIds: JSON.parse(process.env.ARTIFACT_IDS), + tags: JSON.parse(process.env.TAGS), + requireTags: process.env.REQUIRE_TAGS === 'true', + }); + core.setOutput('releases', releases); diff --git a/actions/release/verify/index.js b/actions/release/verify/index.js new file mode 100644 index 00000000..0c9824aa --- /dev/null +++ b/actions/release/verify/index.js @@ -0,0 +1,117 @@ +class ReleaseVerification { + constructor({ github, context }) { + this.github = github; + this.context = context; + } + + async verify({ artifactIds, tags, requireTags = false }) { + for (const [name, values] of Object.entries({ artifactIds, tags })) { + if ( + !Array.isArray(values) || + !values.length || + values.length > 256 || + values.some( + (value) => + typeof value !== "string" || !value || value.trim() !== value, + ) || + new Set(values).size !== values.length + ) { + throw new Error( + `${name} must be a nonempty array of unique strings, with at most 256 entries.`, + ); + } + } + for (const artifactId of artifactIds) { + if ( + !/^[1-9]\d*$/.test(artifactId) || + !Number.isSafeInteger(Number(artifactId)) + ) { + throw new Error("Expected positive, safe artifact IDs."); + } + } + for (const artifactId of artifactIds) await this.verifyArtifact(artifactId); + const releases = []; + for (const tag of tags) + releases.push({ + tag, + published: await this.isPublished(tag, requireTags), + }); + return releases; + } + + async verifyArtifact(artifactId) { + const { data: artifact } = await this.github.rest.actions.getArtifact({ + ...this.context.repo, + artifact_id: Number(artifactId), + }); + if ( + artifact.expired || + artifact.workflow_run?.id !== this.context.runId || + artifact.workflow_run?.head_sha !== this.context.sha + ) { + throw new Error( + "Release artifacts must be unexpired and belong to this run and commit.", + ); + } + } + + async isPublished(tag, requireTag) { + let object; + try { + object = ( + await this.github.rest.git.getRef({ + ...this.context.repo, + ref: `tags/${tag}`, + }) + ).data.object; + } catch (error) { + if (error.status !== 404) throw error; + } + if (!object && requireTag) + throw new Error(`Prepared release tag ${tag} does not exist.`); + if (object) { + let depth = 0; + while (object.type === "tag" && depth++ < 10) { + object = ( + await this.github.rest.git.getTag({ + ...this.context.repo, + tag_sha: object.sha, + }) + ).data.object; + } + if (object.type !== "commit" || object.sha !== this.context.sha) { + throw new Error( + `Release tag ${tag} must identify this run's source commit.`, + ); + } + } + const release = await this.findRelease(tag); + if (release && !object) + throw new Error(`Release ${tag} has no matching Git tag.`); + return Boolean(release && !release.draft); + } + + async findRelease(tag) { + try { + return ( + await this.github.rest.repos.getReleaseByTag({ + ...this.context.repo, + tag, + }) + ).data; + } catch (error) { + if (error.status !== 404) throw error; + // The by-tag endpoint only guarantees published releases; include drafts on retry. + const releases = await this.github.paginate( + this.github.rest.repos.listReleases, + { + ...this.context.repo, + per_page: 100, + }, + ); + return releases.find((release) => release.tag_name === tag); + } + } +} + +module.exports = { ReleaseVerification }; diff --git a/actions/release/verify/index.test.js b/actions/release/verify/index.test.js new file mode 100644 index 00000000..73405934 --- /dev/null +++ b/actions/release/verify/index.test.js @@ -0,0 +1,189 @@ +const assert = require("node:assert/strict"); +const { test } = require("node:test"); +const { ReleaseVerification } = require("./index.js"); + +const context = { + repo: { owner: "example", repo: "project" }, + runId: 42, + sha: "source-commit", +}; +const missing = () => { + throw Object.assign(new Error("Not found"), { status: 404 }); +}; +function arrange({ + artifact = {}, + tags = {}, + releases = [], + drafts = [], + annotated = {}, +} = {}) { + const github = { + rest: { + actions: { + getArtifact: async () => ({ + data: { + expired: false, + workflow_run: { id: 42, head_sha: context.sha }, + ...artifact, + }, + }), + }, + git: { + getRef: async ({ ref }) => + tags[ref.slice(5)] + ? { data: { object: tags[ref.slice(5)] } } + : missing(), + getTag: async ({ tag_sha }) => ({ + data: { object: annotated[tag_sha] }, + }), + }, + repos: { + getReleaseByTag: async ({ tag }) => + releases.some((release) => release.tag_name === tag) + ? { data: releases.find((release) => release.tag_name === tag) } + : missing(), + listReleases: () => {}, + }, + }, + paginate: async () => drafts, + }; + const verification = new ReleaseVerification({ github, context }); + const verify = (options = {}) => + verification.verify({ artifactIds: ["10"], tags: ["v1.2.3"], ...options }); + return { github, verify }; +} +const commit = { type: "commit", sha: context.sha }; + +test("accepts source releases without a tag and artifacts from the current run, including retries", async () => { + assert.deepEqual(await arrange().verify(), [ + { tag: "v1.2.3", published: false }, + ]); +}); +test("rejects expired artifacts, another run or commit, and missing source metadata", async () => { + for (const artifact of [ + { expired: true }, + { workflow_run: null }, + { workflow_run: { id: 41, head_sha: context.sha } }, + { workflow_run: { id: 42, head_sha: "old" } }, + ]) { + await assert.rejects(arrange({ artifact }).verify(), /this run and commit/); + } +}); +test("rejects malformed, unsafe, duplicate and empty inputs before API calls", async () => { + const { github, verify } = arrange(); + github.rest.actions.getArtifact = () => + assert.fail("Invalid input must not call GitHub."); + for (const artifactIds of [ + [], + {}, + ["0"], + ["-1"], + ["1,2"], + ["9007199254740992"], + ["1", "1"], + [1], + [" 1"], + ]) { + await assert.rejects(verify({ artifactIds }), /artifactIds|artifact IDs/); + } + for (const tags of [[], {}, [""], ["v1", "v1"], [null]]) + await assert.rejects(verify({ tags }), /tags/); +}); +test("requires prepared tags only when requested", async () => { + await assert.rejects( + arrange().verify({ requireTags: true }), + /does not exist/, + ); + assert.equal( + ( + await arrange({ tags: { "v1.2.3": commit } }).verify({ + requireTags: true, + }) + )[0].published, + false, + ); +}); +test("verifies every tag and artifact for independent package releases", async () => { + const tags = { "@scope/a@1.2.3": commit, "@scope/b@2.0.0": commit }; + const { github, verify } = arrange({ + tags, + releases: [{ tag_name: "@scope/a@1.2.3", draft: false }], + }); + const ids = []; + const getArtifact = github.rest.actions.getArtifact; + github.rest.actions.getArtifact = async (args) => { + ids.push(args.artifact_id); + return getArtifact(args); + }; + assert.deepEqual( + await verify({ + artifactIds: ["10", "11"], + tags: Object.keys(tags), + requireTags: true, + }), + [ + { tag: "@scope/a@1.2.3", published: true }, + { tag: "@scope/b@2.0.0", published: false }, + ], + ); + assert.deepEqual(ids, [10, 11]); +}); +test("recognizes published lightweight and nested annotated tags", async () => { + for (const tag of [commit, { type: "tag", sha: "outer" }]) { + const { verify } = arrange({ + tags: { "v1.2.3": tag }, + annotated: { outer: { type: "tag", sha: "inner" }, inner: commit }, + releases: [{ tag_name: "v1.2.3", draft: false }], + }); + assert.equal((await verify())[0].published, true); + } +}); +test("recovers drafts returned directly or through the paginated release list", async () => { + for (const key of ["releases", "drafts"]) { + const { verify } = arrange({ + tags: { "v1.2.3": commit }, + [key]: [{ tag_name: "v1.2.3", draft: true }], + }); + assert.equal((await verify())[0].published, false); + } +}); +test("rejects conflicting commits, non-commit objects and annotation cycles", async () => { + for (const object of [ + { type: "commit", sha: "other" }, + { type: "tree", sha: context.sha }, + { type: "tag", sha: "loop" }, + ]) { + await assert.rejects( + arrange({ + tags: { "v1.2.3": object }, + annotated: { loop: object }, + }).verify(), + /source commit/, + ); + } +}); +test("rejects releases with missing tags", async () => { + for (const draft of [true, false]) { + await assert.rejects( + arrange({ releases: [{ tag_name: "v1.2.3", draft }] }).verify(), + /no matching Git tag/, + ); + } +}); +test("fails closed on artifact, tag, release and pagination API errors", async () => { + for (const endpoint of ["artifact", "ref", "tag", "release", "list"]) { + const { github, verify } = arrange({ + tags: { "v1.2.3": { type: "tag", sha: "annotated" } }, + annotated: { annotated: commit }, + }); + const fail = () => { + throw Object.assign(new Error("Forbidden"), { status: 403 }); + }; + if (endpoint === "artifact") github.rest.actions.getArtifact = fail; + if (endpoint === "ref") github.rest.git.getRef = fail; + if (endpoint === "tag") github.rest.git.getTag = fail; + if (endpoint === "release") github.rest.repos.getReleaseByTag = fail; + if (endpoint === "list") github.paginate = fail; + await assert.rejects(verify(), /Forbidden/); + } +});