diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bb3ecd..a1124f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,6 +115,14 @@ on: AZURE_CERT_PROFILE: required: false + outputs: + version: + description: "Resolved release version without a leading v" + value: ${{ jobs.version.outputs.version }} + prerelease: + description: "Whether the resolved version is a prerelease" + value: ${{ jobs.version.outputs.prerelease }} + permissions: contents: read @@ -260,6 +268,9 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/verify.sh --arch universal + - name: Note missing macOS verification + if: github.ref == 'refs/heads/release' && hashFiles('packaging/macos/verify.sh') == '' + run: echo "::warning::packaging/macos/verify.sh is missing, so this release was not checked for a signature or notarization ticket." - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..d08d302 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,78 @@ +name: "Supply chain" + +on: + workflow_call: + inputs: + version: + description: "Resolved release version without a leading v (needs.release.outputs.version)" + type: string + required: true + +permissions: + contents: read + +jobs: + attest: + name: SBOM and signatures + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Download release artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: release-* + path: artifacts + merge-multiple: false + + - name: Collect release files + shell: bash + run: | + set -euo pipefail + mkdir -p subjects + find artifacts -type f -print0 > "$PWD/release-files" + if [ ! -s "$PWD/release-files" ]; then + echo "::error::no release files were downloaded" + exit 1 + fi + while IFS= read -r -d '' file; do + base="$(basename "$file")" + if [ -e "subjects/$base" ]; then + echo "::error::duplicate release file name: $base" + exit 1 + fi + cp "$file" "subjects/$base" + done < "$PWD/release-files" + + - name: Generate the CycloneDX SBOM + uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + with: + path: subjects + format: cyclonedx-json + output-file: sbom.cyclonedx.json + upload-artifact: "false" + upload-release-assets: "false" + dependency-snapshot: "false" + + - name: Sign the release files + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: subjects/* + + - name: Attest the SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: subjects/* + sbom-path: sbom.cyclonedx.json + + - name: Attach the SBOM to the draft release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + tag="v${VERSION#v}" + gh release upload "$tag" sbom.cyclonedx.json --clobber diff --git a/README.md b/README.md index 5b236fb..2e1bf7e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,32 @@ jobs: List every signing secret explicitly. The signing jobs run in the caller repo's `release` environment and read the values from there, but only for the names listed here: an unlisted name is empty. On the `release` branch the macOS and Windows jobs fail when their signing secrets are empty, rather than shipping unsigned builds. +macOS and Windows packages are signed by the app's own packaging scripts. `release.yml` does not request `id-token` or `attestations`, so existing callers keep building as they do now. + +Build provenance, a CycloneDX SBOM, and a Sigstore signature of the published files are a second job. Add it only in an app that is ready to grant those permissions. `release.yml` does not call `supply-chain.yml`. + +```yaml + supply-chain: + needs: release + if: github.ref == 'refs/heads/release' + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + uses: storytold/craft-common-actions/.github/workflows/supply-chain.yml@main + with: + version: ${{ needs.release.outputs.version }} +``` + +After a release that ran the extra job: + +```sh +gh attestation verify PhotoCraft-1.2.3-macos-universal.zip --owner storytold +``` + +The command checks the signature and prints the repository and commit that built the file. The SBOM is attached to the draft release as `sbom.cyclonedx.json`. + `craft-fonts-ref` feeds the desktop builds. It defaults to the suite's craft-fonts pin, so one bump here reaches every app's next release; set it only when an app needs another commit. Every desktop build runs with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`: an app whose `build.rs` reads diff --git a/tests/test_supply_chain.py b/tests/test_supply_chain.py new file mode 100644 index 0000000..420000b --- /dev/null +++ b/tests/test_supply_chain.py @@ -0,0 +1,112 @@ +"""The supply-chain workflow stays opt-in so current release callers keep working.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] +RELEASE = ROOT / ".github/workflows/release.yml" +SUPPLY = ROOT / ".github/workflows/supply-chain.yml" + + +def load(path): + # BaseLoader keeps the workflow key "on" as a string. SafeLoader treats it as boolean true. + return yaml.load(path.read_text(), Loader=yaml.BaseLoader) + + +class SupplyChainOptInTests(unittest.TestCase): + def setUp(self): + self.release = load(RELEASE) + self.supply = load(SUPPLY) + + def test_release_workflow_does_not_call_supply_chain(self): + text = RELEASE.read_text() + self.assertNotIn("supply-chain.yml", text) + self.assertNotIn("id-token", text) + self.assertNotIn("attestations:", text) + + def test_release_exposes_version_for_an_optional_follow_up_job(self): + outputs = self.release["on"]["workflow_call"]["outputs"] + self.assertIn("version", outputs) + self.assertIn("jobs.version.outputs.version", outputs["version"]["value"]) + + def test_missing_macos_verify_script_warns_and_does_not_fail_the_job(self): + steps = self.release["jobs"]["macos"]["steps"] + note = next(step for step in steps if step.get("name") == "Note missing macOS verification") + self.assertIn("hashFiles('packaging/macos/verify.sh') == ''", note["if"]) + self.assertIn("::warning::", note["run"]) + self.assertNotIn("exit 1", note["run"]) + + def test_supply_chain_workflow_is_separate_and_signs_with_sigstore(self): + job = self.supply["jobs"]["attest"] + permissions = job["permissions"] + self.assertEqual(permissions["id-token"], "write") + self.assertEqual(permissions["attestations"], "write") + self.assertEqual(permissions["contents"], "write") + uses = [step["uses"] for step in job["steps"] if "uses" in step] + self.assertTrue(any(item.startswith("actions/attest@") for item in uses)) + self.assertTrue(any(item.startswith("anchore/sbom-action@") for item in uses)) + sbom = next(step for step in job["steps"] if step.get("uses", "").startswith("anchore/sbom-action@")) + self.assertEqual(sbom["with"]["upload-release-assets"], "false") + self.assertEqual(sbom["with"]["format"], "cyclonedx-json") + + def test_artifact_download_preserves_artifact_boundaries(self): + steps = self.supply["jobs"]["attest"]["steps"] + download = next(step for step in steps if step.get("name") == "Download release artifacts") + self.assertEqual(download["with"]["merge-multiple"], "false") + + def test_collection_runs_before_sbom_generation_and_attestation(self): + steps = self.supply["jobs"]["attest"]["steps"] + collection = next(step for step in steps if step.get("name") == "Collect release files") + for index, step in enumerate(steps): + if step.get("uses", "").startswith(("anchore/sbom-action@", "actions/attest@")): + self.assertLess(steps.index(collection), index) + self.assertNotIn("if", step) + self.assertNotIn("continue-on-error", collection) + + def collect_files(self, files): + steps = self.supply["jobs"]["attest"]["steps"] + collection = next(step for step in steps if step.get("name") == "Collect release files") + with tempfile.TemporaryDirectory(prefix="supply-chain-test-") as directory: + work = Path(directory) + (work / "artifacts").mkdir() + for name, contents in files.items(): + path = work / "artifacts" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + result = subprocess.run(["bash", "-c", collection["run"]], cwd=work, + text=True, capture_output=True, timeout=15) + subjects = {path.name: path.read_bytes() for path in (work / "subjects").iterdir()} if (work / "subjects").is_dir() else {} + return result, subjects + + def test_collection_rejects_duplicate_basenames_across_artifacts(self): + result, subjects = self.collect_files({ + "release-linux/app.zip": b"linux package", + "release-macos/app.zip": b"macos package", + }) + self.assertNotEqual(result.returncode, 0, result) + self.assertIn("::error::duplicate release file name: app.zip", result.stdout) + self.assertEqual(len(subjects), 1) + + def test_collection_preserves_all_distinct_files(self): + files = { + "release-linux/nested/app linux.tar.gz": b"linux\x00package", + "release-macos/app macos.zip": b"macos package\n", + } + result, subjects = self.collect_files(files) + self.assertEqual(result.returncode, 0, result) + self.assertEqual(subjects, {Path(name).name: contents for name, contents in files.items()}) + + def test_collection_rejects_missing_release_files(self): + result, subjects = self.collect_files({}) + self.assertNotEqual(result.returncode, 0, result) + self.assertIn("::error::no release files were downloaded", result.stdout) + self.assertEqual(subjects, {}) + + +if __name__ == "__main__": + unittest.main()