diff --git a/Makefile b/Makefile index 393fe5f..379fe52 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ validate: test manifests shellcheck test: ruby -Itest test/release_manifest_test.rb ruby -Itest test/release_notes_test.rb + ruby -Itest test/web_ui_pin_test.rb manifests: ruby script/manifest validate examples/release.yml @@ -18,6 +19,7 @@ manifests: shellcheck: bash -n scripts/*.sh + ruby -c script/verify-web-ui-pin actionlint: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 diff --git a/README.md b/README.md index a347274..f377270 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,11 @@ suite Release record. It performs no Rust or Flutter compilation. 1. Copy `examples/release.yml` to `releases/YYYY.MM.PATCH.yml`. 2. Set each component to a full 40-character commit SHA. 3. Set versions and tags to the metadata already committed at those SHAs. -4. Commit the manifest directly to `main` and wait for `Validate release manifests`. -5. Approve the `release` Environment when approval is configured. +4. Confirm the server commit's `synctv-web-ui/web-ui.production.toml` pins the same App + repository and commit as the manifest. Its production `revision` must equal + that full commit SHA. +5. Commit the manifest directly to `main` and wait for `Validate release manifests`. +6. Approve the `release` Environment when approval is configured. Run the same validation locally: diff --git a/docs/runbook.md b/docs/runbook.md index d12af4c..c94d6ad 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -2,7 +2,9 @@ ## Normal release -1. Prepare final version commits in both component repositories. +1. Prepare final version commits in both component repositories. The server's + `synctv-web-ui/web-ui.production.toml` must pin the final App commit in both `revision` + and `commit`. 2. Add one `releases/YYYY.MM.PATCH.yml` manifest and commit it directly to `main`. 3. Review the `Validate release manifests` result. 4. Approve the `release` Environment when approval is configured. diff --git a/lib/web_ui_pin.rb b/lib/web_ui_pin.rb new file mode 100644 index 0000000..cfd1d91 --- /dev/null +++ b/lib/web_ui_pin.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "json" + +class WebUiPin + REQUIRED_KEYS = %w[kind repository revision commit].freeze + GITHUB_REPOSITORY = %r{\A(?:https://github\.com/|ssh://git@github\.com/|git@github\.com:)([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?\z} + COMMIT = /\A[0-9a-f]{40}\z/ + + def self.verify!(path, expected_repository:, expected_commit:) + source = parse_source(File.read(path, encoding: "UTF-8")) + missing = REQUIRED_KEYS - source.keys + raise ArgumentError, "Web UI source is missing #{missing.join(", ")}" unless missing.empty? + raise ArgumentError, "Web UI source kind must be git" unless source.fetch("kind") == "git" + + repository = github_repository(source.fetch("repository")) + unless repository == expected_repository + raise ArgumentError, "Web UI repository #{repository} differs from #{expected_repository}" + end + + commit = source.fetch("commit") + raise ArgumentError, "Web UI commit must be a full lowercase SHA" unless COMMIT.match?(commit) + revision = source.fetch("revision") + unless revision == commit + raise ArgumentError, "Web UI production revision must equal its pinned commit" + end + return true if commit == expected_commit + + raise ArgumentError, "Web UI commit #{commit} differs from #{expected_commit}" + end + + def self.parse_source(text) + current_table = nil + source = {} + text.each_line.with_index(1) do |raw_line, line_number| + line = raw_line.strip + next if line.empty? || line.start_with?("#") + + if (table = line.match(/\A\[([A-Za-z0-9_.-]+)\]\z/)) + current_table = table[1] + next + end + next unless current_table == "source" + + assignment = line.match(/\A([A-Za-z0-9-]+)\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?\z/) + raise ArgumentError, "invalid Web UI source value on line #{line_number}" unless assignment + + key = assignment[1] + raise ArgumentError, "duplicate Web UI source key #{key}" if source.key?(key) + + source[key] = JSON.parse(assignment[2]) + end + raise ArgumentError, "Web UI configuration is missing [source]" if source.empty? + + source + end + + def self.github_repository(value) + match = GITHUB_REPOSITORY.match(value) + raise ArgumentError, "Web UI repository must be a GitHub repository URL" unless match + + match[1] + end + + private_class_method :parse_source, :github_repository +end diff --git a/script/verify-web-ui-pin b/script/verify-web-ui-pin new file mode 100644 index 0000000..241923b --- /dev/null +++ b/script/verify-web-ui-pin @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/web_ui_pin" + +begin + config = ARGV.fetch(0) { abort "usage: verify-web-ui-pin CONFIG REPOSITORY COMMIT" } + repository = ARGV.fetch(1) { abort "usage: verify-web-ui-pin CONFIG REPOSITORY COMMIT" } + commit = ARGV.fetch(2) { abort "usage: verify-web-ui-pin CONFIG REPOSITORY COMMIT" } + abort "usage: verify-web-ui-pin CONFIG REPOSITORY COMMIT" unless ARGV.length == 3 + + WebUiPin.verify!(config, expected_repository: repository, expected_commit: commit) + puts "Web UI pins #{repository}@#{commit}." +rescue ArgumentError => error + warn error.message + exit 1 +end diff --git a/scripts/preflight.sh b/scripts/preflight.sh index f2ced13..939e44a 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -54,8 +54,11 @@ download_file "$BACKEND_REPOSITORY" "$BACKEND_COMMIT" docs/package-lock.json "$t download_file "$BACKEND_REPOSITORY" "$BACKEND_COMMIT" docs/src/lib/project.ts "$tmp_dir/project.ts" download_file "$BACKEND_REPOSITORY" "$BACKEND_COMMIT" docker-compose.yml "$tmp_dir/docker-compose.yml" download_file "$BACKEND_REPOSITORY" "$BACKEND_COMMIT" helm/synctv/README.md "$tmp_dir/helm-readme.md" +download_file "$BACKEND_REPOSITORY" "$BACKEND_COMMIT" synctv-web-ui/web-ui.production.toml "$tmp_dir/web-ui.production.toml" download_file "$APP_REPOSITORY" "$APP_COMMIT" pubspec.yaml "$tmp_dir/pubspec.yaml" +ruby script/verify-web-ui-pin "$tmp_dir/web-ui.production.toml" "$APP_REPOSITORY" "$APP_COMMIT" + cargo_version="$(ruby -e ' text = File.read(ARGV.fetch(0)) section = text[/^\[workspace\.package\]\s*$.*?(?=^\[|\z)/m] or abort "workspace.package is missing" diff --git a/test/web_ui_pin_test.rb b/test/web_ui_pin_test.rb new file mode 100644 index 0000000..2f4da50 --- /dev/null +++ b/test/web_ui_pin_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tempfile" +require_relative "../lib/web_ui_pin" + +class WebUiPinTest < Minitest::Test + COMMIT = "b" * 40 + + def verify(source, repository: "synctv-org/synctv-app", commit: COMMIT) + file = Tempfile.new(["web-ui", ".toml"]) + file.write("schema-version = 1\n\n[source]\n#{source}") + file.close + WebUiPin.verify!(file.path, expected_repository: repository, expected_commit: commit) + ensure + file&.unlink + end + + def test_accepts_matching_https_pin + assert verify(<<~TOML) + kind = "git" + repository = "https://github.com/synctv-org/synctv-app.git" + revision = "#{COMMIT}" + commit = "#{COMMIT}" + TOML + end + + def test_accepts_matching_ssh_pin + assert verify(<<~TOML) + kind = "git" + repository = "git@github.com:synctv-org/synctv-app.git" + revision = "#{COMMIT}" + commit = "#{COMMIT}" + TOML + end + + def test_rejects_a_floating_production_revision + error = assert_raises(ArgumentError) do + verify(<<~TOML) + kind = "git" + repository = "https://github.com/synctv-org/synctv-app.git" + revision = "refs/tags/v1.2.3" + commit = "#{COMMIT}" + TOML + end + + assert_includes error.message, "revision must equal its pinned commit" + end + + def test_rejects_non_git_source + error = assert_raises(ArgumentError) do + verify(<<~TOML) + kind = "dist" + repository = "https://github.com/synctv-org/synctv-app.git" + revision = "#{COMMIT}" + commit = "#{COMMIT}" + TOML + end + + assert_includes error.message, "kind must be git" + end + + def test_rejects_repository_mismatch + error = assert_raises(ArgumentError) do + verify(<<~TOML) + kind = "git" + repository = "https://github.com/other/app.git" + revision = "#{COMMIT}" + commit = "#{COMMIT}" + TOML + end + + assert_includes error.message, "differs from synctv-org/synctv-app" + end + + def test_rejects_commit_mismatch + mismatched_commit = "a" * 40 + error = assert_raises(ArgumentError) do + verify(<<~TOML) + kind = "git" + repository = "https://github.com/synctv-org/synctv-app.git" + revision = "#{mismatched_commit}" + commit = "#{mismatched_commit}" + TOML + end + + assert_includes error.message, "differs from #{COMMIT}" + end + + def test_rejects_duplicate_source_keys + error = assert_raises(ArgumentError) do + verify(<<~TOML) + kind = "git" + kind = "git" + repository = "https://github.com/synctv-org/synctv-app.git" + revision = "#{COMMIT}" + commit = "#{COMMIT}" + TOML + end + + assert_includes error.message, "duplicate Web UI source key kind" + end +end