Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/__shared-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/__test-action-release-verify.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions actions/release/verify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!-- header:start -->

# ![Icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJmZWF0aGVyIGZlYXRoZXItc2hpZWxkIiBjb2xvcj0iYmx1ZSI+PHBhdGggZD0iTTEyIDIyczgtNCA4LTEwVjVsLTgtMy04IDN2N2MwIDYgOCAxMCA4IDEweiI+PC9wYXRoPjwvc3ZnPg==) GitHub Action: Verify release sources

<!-- header:end -->
<!-- overview:start -->

## Overview

Verify that release artifacts belong to this run and commit, reject conflicting tags, and identify already published releases without changing repository state.

<!-- overview:end -->
<!-- usage:start -->

## Usage

```yaml
- uses: hoverkraft-tech/ci-github-publish/actions/release/verify@<commit-sha>
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"
```

<!-- usage:end -->
<!-- inputs:start -->

## 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` |

<!-- inputs:end -->
<!-- secrets:start -->
<!-- secrets:end -->
<!-- outputs:start -->

## Outputs

| **Output** | **Description** |
| -------------- | ----------------------------------------------------------------------------------------------------------- |
| **`releases`** | JSON array of objects containing tag and published. A matching draft or absent release has published=false. |

<!-- outputs:end -->
41 changes: 41 additions & 0 deletions actions/release/verify/action.yml
Original file line number Diff line number Diff line change
@@ -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);
117 changes: 117 additions & 0 deletions actions/release/verify/index.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading