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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 3 additions & 1 deletion docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 66 additions & 0 deletions lib/web_ui_pin.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions script/verify-web-ui-pin
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions scripts/preflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
103 changes: 103 additions & 0 deletions test/web_ui_pin_test.rb
Original file line number Diff line number Diff line change
@@ -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