Skip to content

Commit 5e633e3

Browse files
committed
Add a changelog
1 parent 4c13415 commit 5e633e3

4 files changed

Lines changed: 363 additions & 1 deletion

File tree

.github/workflows/CI.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ jobs:
128128
println("Version $VERSION OK on $BRANCH")
129129
end
130130
exit(0)
131+
- name: Changelog Check
132+
run: julia test/check_changelog.jl
131133
- name: Get codestyle settings
132134
run: wget https://raw.githubusercontent.com/JuliaQuantumControl/JuliaQuantumControl/master/.JuliaFormatter.toml
133135
- name: Install JuliaFormatter and format

CHANGELOG.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<!--
2+
SPDX-FileCopyrightText: © 2022 Michael Goerz <mail@michaelgoerz.net>
3+
4+
SPDX-License-Identifier: MIT OR CC0-1.0
5+
-->
6+
7+
# Release Notes
8+
9+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
10+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11+
12+
Also see the [GitHub Releases](https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases).
13+
14+
## [Unreleased]
15+
16+
* Added: `size` and `eltype` for gradient-generator operators [[#17], [#23]]
17+
* Added: Matrix and vector interfaces for gradient-generator operators and vectors [[#18]]
18+
* Added: An extension for [ArrayInterface.jl](https://github.com/JuliaArrays/ArrayInterface.jl) [[#19]]
19+
* Added: A simplified constructor for `GradgenOperator` [[#20]]
20+
* Added: Conversion between compatible `GradVector`s [[#21]]
21+
* Added: Flat matrix-vector multiplication [[#22]]
22+
* Added: Per-file copyright and licensing information following the [REUSE specification](https://reuse.software). Source code remains under the MIT License; documentation is additionally available under `CC-BY-4.0`, and trivial files under `CC0-1.0`.
23+
24+
## [v0.1.8] — 2024-09-04
25+
26+
* Removed: The dependency on `QuantumControlBase`; `QuantumGradientGenerators` is now self-contained
27+
28+
## [v0.1.7] — 2024-07-27
29+
30+
* Added: Support for the in-place interface
31+
32+
## [v0.1.6] — 2024-04-21
33+
34+
* Added: Support for `get_parameters`
35+
36+
## [v0.1.5] — 2024-01-22
37+
38+
* Changed: Compatibility with `QuantumControlBase` 0.9
39+
40+
## [v0.1.4] — 2024-01-08
41+
42+
* Added: An `rng` argument to `random_state`
43+
44+
## [v0.1.3] — 2023-10-06
45+
46+
* Changed: The minimum supported Julia version is now 1.9
47+
48+
## [v0.1.2] — 2023-05-15
49+
50+
* Fixed: The linalg interface
51+
52+
## [v0.1.1] — 2023-02-16
53+
54+
* Changed: `QuantumPropagators` is now an indirect dependency
55+
56+
## [v0.1.0] — 2023-01-28
57+
58+
Initial public release
59+
60+
[Unreleased]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/compare/v0.1.8..HEAD
61+
[v0.1.8]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.8
62+
[v0.1.7]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.7
63+
[v0.1.6]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.6
64+
[v0.1.5]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.5
65+
[v0.1.4]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.4
66+
[v0.1.3]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.3
67+
[v0.1.2]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.2
68+
[v0.1.1]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.1
69+
[v0.1.0]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/releases/tag/v0.1.0
70+
[#17]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/17
71+
[#18]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/18
72+
[#19]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/19
73+
[#20]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/20
74+
[#21]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/21
75+
[#22]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/22
76+
[#23]: https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl/pull/23

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# SPDX-License-Identifier: MIT OR CC0-1.0
44

5-
.PHONY: help test docs clean distclean devrepl codestyle servedocs reuse
5+
.PHONY: help test docs clean distclean devrepl codestyle check-changelog changelog servedocs reuse
66
.DEFAULT_GOAL := help
77

88
JULIA ?= julia
@@ -64,8 +64,15 @@ clean: ## Clean up build/doc/testing artifacts
6464

6565
codestyle: test/Manifest.toml .JuliaFormatter.toml ## Apply the codestyle to the entire project
6666
$(JULIA) --project=test -e 'using JuliaFormatter; format(".", verbose=true)'
67+
$(MAKE) check-changelog
6768
@echo "Done. Consider using 'make devrepl'"
6869

70+
check-changelog: ## Validate the links in CHANGELOG.md
71+
$(JULIA) test/check_changelog.jl
72+
73+
changelog: ## Validate CHANGELOG.md and add any missing issue/PR link targets
74+
$(JULIA) test/check_changelog.jl --fix
75+
6976
reuse: ## Check REUSE/SPDX licensing compliance (requires uv or pipx)
7077
@if command -v reuse >/dev/null 2>&1; then \
7178
reuse lint; \

test/check_changelog.jl

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# SPDX-FileCopyrightText: © 2022 Michael Goerz <mail@michaelgoerz.net>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
# Validate (and optionally fix) the reference-style links in CHANGELOG.md.
6+
#
7+
# The default check mode is purely textual: it makes no network calls and
8+
# inspects no git state. It verifies that every reference used in the prose
9+
# (e.g. `[#97]`, `[v0.8.0]`) has a matching link definition, that no definition
10+
# is unused or duplicated, that each version link points to its own release tag,
11+
# and that the `[Unreleased]` link compares against the latest version heading
12+
# in the file.
13+
#
14+
# The `--fix` mode (only) may make network calls: any missing `[#N]`
15+
# issue/pull-request link target is added, consulting the GitHub API to decide
16+
# whether `#N` is an issue or a pull request and to confirm it exists:
17+
#
18+
# * If the API confirms `#N` exists, the correct `issues/` or `pull/` URL is
19+
# added.
20+
# * If the API definitively reports that `#N` does not exist (HTTP 404), it is
21+
# treated as a typo: no link is added and the reference is reported.
22+
# * If the API cannot be reached (offline, rate-limited), verification is
23+
# skipped and an `issues/` URL is added (GitHub redirects `issues/N` ↔
24+
# `pull/N`, so it still resolves).
25+
#
26+
# Reading issues/PRs of a public repository uses the `gh` CLI
27+
# when available, and otherwise the unauthenticated public API via `curl`.
28+
#
29+
# Run with `julia test/check_changelog.jl [--fix] [path]` (no project
30+
# dependencies), or via `make check-changelog` / `make changelog`. Exits
31+
# non-zero on any remaining problem.
32+
33+
const FIX = "--fix" in ARGS
34+
const positional = filter(a -> a != "--fix", ARGS)
35+
const CHANGELOG =
36+
isempty(positional) ? joinpath(@__DIR__, "..", "CHANGELOG.md") : positional[1]
37+
const FALLBACK_BASE = "https://github.com/JuliaQuantumControl/QuantumGradientGenerators.jl"
38+
39+
const DEFINITION = r"^\[([^\]]+)\]:[ \t]*(\S+)[ \t]*$"
40+
const INLINE_LABEL = r"\[([^\[\]]+)\]\(" # `[text](url)` — not a reference
41+
const REFERENCE = r"\[([^\[\]]+)\]" # innermost `[label]`
42+
const VERSION_HEADING = r"^##[ \t]+\[(v[0-9]+\.[0-9]+\.[0-9]+)\]"
43+
const ISSUE_LABEL = r"^#([0-9]+)$"
44+
45+
# Parse the file into its link definitions (label => url), the order in which
46+
# labels first appear, duplicated labels, the references used in the prose, and
47+
# the latest version (topmost `## [vX.Y.Z]` heading).
48+
function parse_changelog(lines)
49+
defs = Dict{String,String}()
50+
order = String[]
51+
dups = String[]
52+
body = IOBuffer()
53+
for line in lines
54+
m = match(DEFINITION, line)
55+
if m === nothing
56+
println(body, line)
57+
else
58+
label = m.captures[1]
59+
haskey(defs, label) ? push!(dups, label) : push!(order, label)
60+
defs[label] = m.captures[2]
61+
end
62+
end
63+
prose = String(take!(body))
64+
inline = Set(m.captures[1] for m in eachmatch(INLINE_LABEL, prose))
65+
used = Set(
66+
m.captures[1] for m in eachmatch(REFERENCE, prose) if !(m.captures[1] in inline)
67+
)
68+
latest = nothing
69+
for line in lines
70+
m = match(VERSION_HEADING, line)
71+
if m !== nothing
72+
latest = m.captures[1]
73+
break
74+
end
75+
end
76+
return (; defs, order, dups, used, latest)
77+
end
78+
79+
# The `https://github.com/owner/repo` base, derived from existing definitions so
80+
# the script is not tied to a hardcoded repository, with a fallback constant.
81+
function github_base(defs)
82+
for url in values(defs)
83+
m = match(r"^(https://github\.com/[^/]+/[^/]+)", url)
84+
m === nothing || return m.captures[1]
85+
end
86+
return FALLBACK_BASE
87+
end
88+
89+
# Run `cmd`, capturing (exitcode, stdout, stderr). exitcode is `nothing` if the
90+
# command could not be launched at all (e.g. the tool is not installed).
91+
function run_capture(cmd)
92+
out = Pipe()
93+
err = Pipe()
94+
proc = try
95+
run(pipeline(ignorestatus(cmd); stdout = out, stderr = err); wait = false)
96+
catch
97+
return (nothing, "", "")
98+
end
99+
close(out.in)
100+
close(err.in)
101+
sout = @async read(out, String)
102+
serr = @async read(err, String)
103+
wait(proc)
104+
return (proc.exitcode, fetch(sout), fetch(serr))
105+
end
106+
107+
# Classify `#n` via the `gh` CLI. Returns :pull, :issue, :missing, :network, or
108+
# :unavailable (gh not installed or not authenticated → try another method).
109+
function classify_gh(slug, n)
110+
jq = "if has(\"pull_request\") then \"pull\" else \"issue\" end"
111+
code, out, err = run_capture(`gh api repos/$slug/issues/$n --jq $jq`)
112+
code === nothing && return :unavailable
113+
if code == 0
114+
kind = strip(out)
115+
kind == "pull" && return :pull
116+
kind == "issue" && return :issue
117+
return :network
118+
end
119+
if occursin("HTTP 404", err) || occursin("Not Found", err)
120+
return :missing
121+
elseif occursin("HTTP 401", err) || occursin(r"auth"i, err)
122+
return :unavailable
123+
end
124+
return :network
125+
end
126+
127+
# Classify `#n` via the unauthenticated public REST API using `curl`.
128+
function classify_curl(slug, n)
129+
url = "https://api.github.com/repos/$slug/issues/$n"
130+
code, out, _ = run_capture(
131+
`curl -sS -H "Accept: application/vnd.github+json" -w $("\n%{http_code}") $url`
132+
)
133+
code === nothing && return :unavailable
134+
code == 0 || return :network
135+
nl = findlast('\n', out)
136+
status = nl === nothing ? "" : strip(out[nextind(out, nl):end])
137+
payload = nl === nothing ? out : out[1:prevind(out, nl)]
138+
status == "200" && return occursin("\"pull_request\"", payload) ? :pull : :issue
139+
status == "404" && return :missing
140+
return :network
141+
end
142+
143+
# Determine whether `#n` is an issue or pull request (or is missing / unknown).
144+
function classify_issue(slug, n)
145+
result = classify_gh(slug, n)
146+
result === :unavailable || return result
147+
result = classify_curl(slug, n)
148+
result === :unavailable ? :network : result
149+
end
150+
151+
# Append missing `[#N]` link targets. Returns (modified::Bool, problems::Vector).
152+
function fix_missing!(lines)
153+
parsed = parse_changelog(lines)
154+
missing = sort!([
155+
parse(Int, match(ISSUE_LABEL, ref).captures[1]) for
156+
ref in parsed.used if occursin(ISSUE_LABEL, ref) && !haskey(parsed.defs, ref)
157+
])
158+
isempty(missing) && return (false, String[])
159+
160+
base = github_base(parsed.defs)
161+
slug = replace(base, "https://github.com/" => "")
162+
nonissue = Tuple{String,String}[]
163+
issues = Dict{Int,String}()
164+
for label in parsed.order
165+
m = match(ISSUE_LABEL, label)
166+
if m === nothing
167+
push!(nonissue, (label, parsed.defs[label]))
168+
else
169+
issues[parse(Int, m.captures[1])] = parsed.defs[label]
170+
end
171+
end
172+
173+
problems = String[]
174+
added = 0
175+
for n in missing
176+
kind = classify_issue(slug, n)
177+
if kind === :missing
178+
push!(
179+
problems,
180+
"reference [#$n] does not exist on GitHub (typo?); no link added",
181+
)
182+
continue
183+
end
184+
issues[n] = kind === :pull ? "$base/pull/$n" : "$base/issues/$n"
185+
note = kind === :network ? " (could not verify via GitHub; assuming issue)" : ""
186+
println("Added link target [#$n]: $(issues[n])$note")
187+
added += 1
188+
end
189+
added == 0 && return (false, problems)
190+
191+
# Rebuild: prose (trailing blanks trimmed), one blank separator, then all
192+
# definitions — non-issue defs in original order, then `#N` defs sorted.
193+
prose_lines = [line for line in lines if !occursin(DEFINITION, line)]
194+
while !isempty(prose_lines) && isempty(strip(prose_lines[end]))
195+
pop!(prose_lines)
196+
end
197+
out = copy(prose_lines)
198+
push!(out, "")
199+
for (label, url) in nonissue
200+
push!(out, "[$label]: $url")
201+
end
202+
for n in sort(collect(keys(issues)))
203+
push!(out, "[#$n]: $(issues[n])")
204+
end
205+
write(CHANGELOG, join(out, "\n") * "\n")
206+
return (true, problems)
207+
end
208+
209+
function validate(lines)
210+
parsed = parse_changelog(lines)
211+
(; defs, dups, used, latest) = parsed
212+
errors = String[]
213+
214+
for u in sort(collect(used))
215+
haskey(defs, u) ||
216+
push!(errors, "reference [$u] is used but has no link definition")
217+
end
218+
for d in sort(collect(keys(defs)))
219+
# `[Unreleased]` is structural and may be defined without a corresponding
220+
# heading on a `release-*` branch, where the heading is temporarily removed.
221+
d == "Unreleased" && continue
222+
d in used || push!(errors, "link definition [$d] is never used")
223+
end
224+
for d in sort(unique(dups))
225+
push!(errors, "link definition [$d] is duplicated")
226+
end
227+
228+
# Each version link should point to its own release tag.
229+
for (label, url) in defs
230+
startswith(label, "v") || continue
231+
endswith(url, "/releases/tag/$label") ||
232+
push!(errors, "[$label] should point to `…/releases/tag/$label`, not `$url`")
233+
end
234+
235+
# The `[Unreleased]` link should compare the latest version against HEAD.
236+
if !haskey(defs, "Unreleased")
237+
push!(errors, "missing the [Unreleased] link definition")
238+
elseif latest === nothing
239+
push!(
240+
errors,
241+
"no `## [vX.Y.Z]` version heading found to anchor the [Unreleased] link",
242+
)
243+
elseif !endswith(defs["Unreleased"], "/compare/$latest..HEAD")
244+
push!(
245+
errors,
246+
"[Unreleased] should compare against the latest version `$latest` " *
247+
"(expected `…/compare/$latest..HEAD`), not `$(defs["Unreleased"])`",
248+
)
249+
end
250+
251+
return errors
252+
end
253+
254+
function main()
255+
isfile(CHANGELOG) || (@error "Not found: $CHANGELOG"; exit(1))
256+
lines = readlines(CHANGELOG)
257+
258+
if FIX
259+
_, problems = fix_missing!(lines)
260+
for p in problems
261+
@warn p
262+
end
263+
lines = readlines(CHANGELOG)
264+
end
265+
266+
errors = validate(lines)
267+
if isempty(errors)
268+
n = count(line -> occursin(DEFINITION, line), lines)
269+
println("CHANGELOG.md: OK ($n link definitions)")
270+
exit(0)
271+
else
272+
@error "CHANGELOG.md has $(length(errors)) problem(s):\n " * join(errors, "\n ")
273+
exit(1)
274+
end
275+
end
276+
277+
main()

0 commit comments

Comments
 (0)