diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c304e483..1cd2fe41 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,5 +1,8 @@ name: codeql +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [main, develop] diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 329d2db5..9bda0b9c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -1,5 +1,8 @@ name: dependency-review +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: pull_request: diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index ab3a15c6..f88039cb 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -1,5 +1,8 @@ name: quality-gate +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [main, develop] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3ff3fbae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: release + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + attestations: write + id-token: write + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.10' + + - name: Setup uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + + - name: Build artifacts + run: uv build + + - name: Generate checksums and manifest + run: | + sha256sum dist/* > dist/SHA256SUMS.txt + python scripts/release/build_release_manifest.py dist dist/release-manifest.json + + - name: Upload release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-artifacts + path: dist/* + + - name: Attest build provenance + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be + with: + subject-path: 'dist/*' + + - name: Publish GitHub release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" dist/* --generate-notes diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 96d9fe5b..14501bde 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -1,5 +1,8 @@ name: scorecards +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [develop] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 66a0a476..3ff5c228 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,8 @@ name: tests +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: push: branches: [main, develop] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..15098c15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- MinerU-backed DOM parsing API for scanned Japanese newspaper PDFs +- Synthetic newspaper fixture generation and structural equivalence checks +- Protected-branch CI, security gates, release provenance workflow, and Git Flow documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c1b4588..bd11f42b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,8 @@ pytest --cov=src/newsdom_api --cov-report=term-missing --cov-fail-under=100 CI installs dependencies from `uv.lock`, and workflow actions are pinned by immutable commit SHA. Keep both policies intact when editing `.github/` automation. +Tagged releases use `.github/workflows/release.yml` to build artifacts, generate SHA256 checksums, emit a JSON manifest, and publish a GitHub Release with provenance attestation. + ## Fixture policy This project intentionally separates public test artifacts from private validation material. diff --git a/README.md b/README.md index fef52e18..6e8aff54 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ This repository ships only synthetic test fixtures and derived structural baseli Development setup, fixture handling rules, and local-only baseline maintenance are documented in `CONTRIBUTING.md`. +Version tags trigger a GitHub-native release workflow that builds distribution artifacts, checksums, and provenance attestations. + +Project history is tracked in `CHANGELOG.md`. + Repository branch workflow is documented in `docs/workflow/git-flow.md`. ## Repository layout diff --git a/docs/adr/0001-openssf-best-practices-badge.md b/docs/adr/0001-openssf-best-practices-badge.md new file mode 100644 index 00000000..658530eb --- /dev/null +++ b/docs/adr/0001-openssf-best-practices-badge.md @@ -0,0 +1,36 @@ +# ADR-0001: OpenSSF Best Practices Badge Enrollment + +## Status + +Accepted + +## Context + +The repository already has branch protection, CI checks, CodeQL, OpenSSF Scorecard, Dependabot, a security policy, locked workflow dependencies, and a planned release pipeline. Scorecard still reports a best-practices gap because the OpenSSF Best Practices badge program has not been started. + +The current repository also has only one organization member and one repository collaborator, so external reviewer capacity is not yet in place. The first tagged release is not available yet because the current PR stack still needs external review before it can merge into protected branches. + +## Decision + +We will **defer** OpenSSF Best Practices badge enrollment until after: + +1. the current protected-branch PR stack is merged, +2. the first tagged release has been produced with release provenance, and +3. at least one external reviewer is available for normal protected-branch review flow. + +## Consequences + +### Positive + +- Keeps focus on finishing concrete repository hardening already underway. +- Avoids starting a badge questionnaire before the release and review processes are stable. +- Preserves a clear, auditable decision in the repository. + +### Negative + +- Scorecard will continue to report the best-practices gap until enrollment is revisited. + +## Follow-up + +- Revisit enrollment after issue #8 and issue #10 are resolved. +- If the repository still intends to pursue the badge at that time, assign an owner and complete the OpenSSF questionnaire. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..e62188d7 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,5 @@ +# Architecture Decision Records + +| ADR | Title | Status | +| --- | ----- | ------ | +| [0001](0001-openssf-best-practices-badge.md) | OpenSSF Best Practices Badge Enrollment | Accepted | diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..2ec200bd --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Project-local automation helpers.""" diff --git a/scripts/release/__init__.py b/scripts/release/__init__.py new file mode 100644 index 00000000..5c59b092 --- /dev/null +++ b/scripts/release/__init__.py @@ -0,0 +1 @@ +"""Release helper scripts for build provenance and manifests.""" diff --git a/scripts/release/build_release_manifest.py b/scripts/release/build_release_manifest.py new file mode 100644 index 00000000..ad5d7d37 --- /dev/null +++ b/scripts/release/build_release_manifest.py @@ -0,0 +1,49 @@ +"""Build a release manifest for generated distribution artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + + +def _sha256(path: Path) -> str: + """Return the SHA-256 digest for a file.""" + + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_manifest(dist_dir: Path) -> dict[str, object]: + """Build manifest metadata for all regular files in a dist directory.""" + + artifacts = [] + for path in sorted(p for p in dist_dir.iterdir() if p.is_file()): + artifacts.append( + { + "name": path.name, + "size": path.stat().st_size, + "sha256": _sha256(path), + } + ) + return {"dist_dir": str(dist_dir), "artifacts": artifacts} + + +def main() -> None: + """Write the release manifest JSON for a distribution directory.""" + + parser = argparse.ArgumentParser() + parser.add_argument("dist_dir", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + + manifest = build_manifest(args.dist_dir) + args.output.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index df150dc5..8a23fa03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,5 +7,8 @@ ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) diff --git a/tests/test_adr_docs.py b/tests/test_adr_docs.py new file mode 100644 index 00000000..a4203c54 --- /dev/null +++ b/tests/test_adr_docs.py @@ -0,0 +1,12 @@ +from pathlib import Path + + +def test_best_practices_decision_adr_exists_and_documents_deferral(): + path = Path("docs/adr/0001-openssf-best-practices-badge.md") + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Status" in text + assert "Accepted" in text + assert "defer" in text.lower() + assert "first tagged release" in text.lower() + assert "external reviewer" in text.lower() diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 00000000..e7c7e499 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,9 @@ +from pathlib import Path + + +def test_changelog_exists_and_uses_keep_a_changelog_format(): + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + assert text.startswith("# Changelog") + assert "Keep a Changelog" in text + assert "## [Unreleased]" in text + assert "Semantic Versioning" in text diff --git a/tests/test_release_pipeline.py b/tests/test_release_pipeline.py new file mode 100644 index 00000000..521b464e --- /dev/null +++ b/tests/test_release_pipeline.py @@ -0,0 +1,29 @@ +import json +from pathlib import Path + + +def test_release_workflow_exists(): + assert Path(".github/workflows/release.yml").exists() + + +def test_release_workflow_mentions_attestation_and_checksums(): + text = Path(".github/workflows/release.yml").read_text(encoding="utf-8") + assert "attest-build-provenance" in text + assert "sha256sum" in text + + +def test_release_manifest_script_exists(): + assert Path("scripts/release/build_release_manifest.py").exists() + + +def test_release_manifest_script_outputs_json(tmp_path: Path): + from scripts.release.build_release_manifest import build_manifest + + dist = tmp_path / "dist" + dist.mkdir() + artifact = dist / "demo.txt" + artifact.write_text("demo", encoding="utf-8") + manifest = build_manifest(dist) + assert manifest["artifacts"][0]["name"] == "demo.txt" + assert manifest["artifacts"][0]["sha256"] + json.loads(json.dumps(manifest)) diff --git a/tests/test_workflow_runtime_env.py b/tests/test_workflow_runtime_env.py new file mode 100644 index 00000000..049ba0ab --- /dev/null +++ b/tests/test_workflow_runtime_env.py @@ -0,0 +1,7 @@ +from pathlib import Path + + +def test_javascript_actions_are_forced_to_node24(): + for workflow_path in sorted(Path(".github/workflows").glob("*.yml")): + text = workflow_path.read_text(encoding="utf-8") + assert "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true" in text, workflow_path