#!/usr/bin/env python # # shipper -- a tool for shipping software import sys, os, readline, re, commands, time, glob, optparse, stat # # State variables # destinations = [] # List of remote directories to update channels = ['ibiblio', 'redhat', 'freshmeat'] whoami = None # Who am I? (Used for FTP logins) date = None # User has not yet set a date package = None # Nor a package name homepage = None # Nor a home page arch = None # The machine architecture keywords = None # Keywords for LSMs freshmeat_name = None # Name of the project ob Freshmeat changelog = None # Project changelog lastchange = None # Last entry in changelog summary = None # One-line summary of the package description = None # Nor a description indextemplate = """ Resource page for %(package)s %(version)s

Resource page for %(package)s %(version)s

%(description)s


%(resourcetable)s

Last modified %(date)s.

""" mailtemplate = """Subject: Announcing release %(version)s of %(package)s Release %(version)s of %(package)s is now available at: %(homepage)s Here are the most recent changes: %(lastchange)s -- shipper, acting for %(whoami)s """ # It's unpleasant that we have to include these here, but # the freshmeat release focus has to be validated even if the # user is offline and the XML-RPC service not accessible. freshmeat_focus_types = ( "N/A", "Initial freshmeat announcement", "Documentation", "Code cleanup", "Minor feature enhancements", "Major feature enhancements", "Minor bugfixes", "Major bugfixes", "Minor security fixes", "Major security fixes", ) def croak(msg): sys.stderr.write("shipper: " + msg + "\n") sys.exit(1) # # Shipping methods # def do_or_die(cmd): "Wither execute a command or fail noisily" if options.verbose: print "***", cmd if os.system(cmd): croak("command '%s' failed!" % cmd) def upload_or_die(cmd): if options.noupload: print cmd else: do_or_die(cmd) def upload(destination, files): # Upload a file via ftp or sftp, handles print "# Uploading to %s" % destination files = filter(os.path.exists, files) if destination.startswith("ftp://"): destination = destination[6:].split("/") host = destination.pop(0) directory = "/".join(destination) commands = ["lftp", "open -u anonymous," + whoami + " " + host + "\n"] if directory: commands.append("cd " + directory + "\n") commands.append("mput " + " ".join(files) + "\n") commands.append("close\n") if options.noupload: print "".join(commands) else: pfp = os.popen(commands.pop(0), "w") pfp.writelines(commands) pfp.close() elif destination.find("::") > -1: upload_or_die("rsync " + " ".join(files) + " " + destination) elif destination.find(":") > -1: (host, directory) = destination.split(":") for file in files: # This is a really ugly way to deal with the problem # of write-protected files in the remote directory. # Unfortunately, sftp(1) is rather brain-dead -- no # way to ignore failure on a remove, and refuses to # do renames with an obscure error message. remote = os.path.join(directory, package, file) upload_or_die("scp " + file + " " + host + ":" + remote+".new;") upload_or_die("ssh %s 'mv -f %s.new %s'" % (host, remote, remote)) else: sys.stderr.write("Don't know what to do with destination %s!") def freshmeat_ship(manifest): "Ship a specified update to freshmeat." if options.verbose: print "Announcing to freshmeat..." upload_or_die("freshmeat-submit <" + manifest[0]) # # Metadata extraction # def grep(pattern, file): "Mine for a specified pattern in a file." fp = open(file) try: while True: line = fp.readline() if not line: return None m = re.search(pattern, line) if m: return m.group(1) finally: fp.close() return None class Specfile: def __init__(self, filename): self.filename = filename self.type = None if filename.endswith(".spec"): self.type = "RPM" self.package = self.extract("Name") self.version = self.extract("Version") self.homepage = self.extract("URL") self.summary = self.extract("Summary") self.arch = self.extract("BuildArch") or commands.getoutput("rpm --showrc | sed -n '/^build arch/s/.* //p'") self.description = self.rpm_get_multiline("description") self.changelog = self.rpm_get_multiline("changelog") elif filename == "control": self.type = "deb" self.name = self.extract("Package") self.version = self.extract("Version").split("-")[0] self.homepage = self.extract("XBS-Home-Page") self.summary = self.extract("Description") self.arch = self.extract("Architecture") if not self.arch: croak("this control file lacks an Architecture field") # FIXME: parse Debian description entries and changelog file self.description = self.changelog = None def extract(self, fld): "Extract a one-line field, possibly embedded as a magic comment." if self.type == "RPM": return grep("^#?"+fld+":\s*(.*)", self.filename) elif self.type == "deb": return grep("^(?:XBS-)?"+fld+": (.*)", self.filename) def rpm_get_multiline(self, fieldname): "Grab everything from leader line to just before the next leader line." global desc fp = open(self.filename) desc = "" gather = False while True: line = fp.readline() if not line: break # Pick up fieldnames *without* translation options. if line.strip() == "%" + fieldname: gather = True continue elif line[0] == "%": gather = False if gather: desc += line fp.close() if desc: return desc.strip() + "\n" else: return None # # Main sequence # try: # # Process options # parser = optparse.OptionParser(usage="%prog: [-h] [-n] [-f] [-v]") parser.add_option("-v", "--verbose", action="store_true", dest="verbose", default=False, help="print progress messages to stdout") parser.add_option("-n", "--noupload", action="store_true", dest="noupload", default=False, help="don't do uploads, just build deliverables") parser.add_option("-N", "--nobuild", action="store_true", dest="nobuild", default=False, help="dump configuration only, no builds or uploads") parser.add_option("-f", "--force", action="store_true", dest="force", default=False, help="force rebuilding of all local deliverables") (options, args) = parser.parse_args() # # Extract metadata and compute control information # def disable(s): channels.remove(s) # Security check, don't let an attacker elevate privileges def securecheck(file): if stat.S_IMODE(os.stat(file).st_mode) & 00002: croak("%s must not be world-writeable!" % file) # Read in variable overrides securecheck(".") home_profile = os.path.join(os.getenv('HOME'), ".shipper") if os.path.exists(home_profile): securecheck(home_profile) execfile(home_profile) here_profile = ".shipper" if os.path.exists(here_profile): securecheck(here_profile) execfile(here_profile) # Set various sensible defaults if not whoami: whoami = os.getenv('USERNAME') + "@" + os.getenv('HOSTNAME') # Where to get the metadata specfiles = glob.glob("*.spec") if len(specfiles) == 1: metadata = Specfile(specfiles[0]) elif os.path.exists("control"): metadata = Specfile("control") else: croak("must be exactly one RPM or dpkg specfile in the directory!") # Get the package name if not package: package = metadata.package if not package: croak("can't get package name!") # Extract the package vers from the specfile or Makefile specvers = metadata.version makevers = None if os.path.exists("Makefile"): makevers = grep("^VERS[A-Z]* *= *(.*)", "Makefile") # Maybe it's a shell command intended to extract version from specfile if makevers and makevers[0] == '$': makevers = commands.getoutput(makevers[7:-1]) if specvers != makevers: croak("specfile version %s != Makefile version %s"%(specvers,makevers)) elif specvers == None: croak("can't get package version") elif specvers[0] not in "0123456789": croak("package version %s appears garbled" % specvers) else: version = specvers # Specfiles may set their own destinations local_destinations = metadata.extract("Destinations") if local_destinations: local_destinations = map(lambda x: x.strip(), local_destinations.split(",")) destinations += local_destinations if not destinations: print "warning: destinations empty, shipping to public channels only." print"# Uploading version %s of %s" % (version, package) # Extract remaining variables for templating if not homepage: homepage = metadata.homepage if not date: date = time.asctime() if not summary: summary = metadata.summary if not description: description = metadata.description if not arch: arch = metadata.arch if not keywords: keywords = metadata.extract("Keywords") if not freshmeat_name: freshmeat_name = metadata.extract("Freshmeat-Name") # Finally, derive the change log and lastchange entry; # we'll need the latter for freshmeat.net freshmeat_lastchange = lastchange = changelog = None # ChangeLog, if present, takes precedence; # we assume if both are present that the specfile log is about packaging. if os.path.exists("ChangeLog"): ifp = open("ChangeLog", "r") changelog = ifp.rea ems in a changes field. freshmeat_lastchange = "See the ChangeLog file for recent changes." elif metadata.changelog: changelog = metadata.changelog lastchange = "" for line in changelog.split("\n"): if not lastchange and (not line.strip() or line[0] == '*'): continue elif line.strip(): lastchange += line + "\n" else: break # This usually produces a lastchange entry that freshmeat will take. freshmeat_lastchange = lastchange # # Now compute the names of deliverables # # These are all potential deliverable files that include the version number tarball = package + "-" + version + ".tar.gz" srcrpm = package + "-" + version + "-1.src.rpm" binrpm = package + "-" + version + "-1." + arch + ".rpm" zip = package + "-" + version + ".zip" lsm = package + "-" + version + ".lsm" # Map web deliverables to explanations for the resource table # Stuff not included here: ANNOUNCE.EMAIL, ANNOUNCE.FRESHMEAT, lsm. stock_deliverables = [ ("README", "roadmap file"), (tarball, "source tarball"), (zip, "ZIP archive"), (binrpm, "installable RPM"), # Generated (srcrpm, "source RPM"), # Generated ("ChangeLog", "change log"), ("CHANGES", "change log"), # Generated ("NEWS", "Project news"), ("HISTORY", "Project history"), ("BUGS", "Known bugs"), ("TODO", "To-do file"), ] # # Might be time to dump # if options.nobuild: for variable in ('destinations', 'channels', 'whoami', 'date', 'package', 'homepage', 'arch', 'keywords', \ 'freshmeat_name', 'summary'): print "%s = %s" % (variable, `eval(variable)`) for variable in ('description', 'changelog', 'lastchange', 'mailtemplate', 'indextemplate'): if not eval(variable): print "No %s" % variable else: print "%s = < os.stat(f2).st_mtime) # Compute the deliverables, we need this even if not rebuilding the index web_deliverables = [] # Anything in the list of standard deliverables is eligible. for (file, explanation) in stock_deliverables: if os.path.exists(file): web_deliverables.append((file, explanation)) # So is anything with an HTML extendion for file in glob.glob('*.html'): if file == 'index.html': continue stem = file[:-4] for ext in ("man", "1", "2", "3", "4", "5", "6", "7", "8", "9", "xml"): if os.path.exists(stem + ext): explanation = "HTML rendering of " + stem + ext break else: explanation = "HTML page." web_deliverables.append((file, explanation)) # Compute final deliverables deliverables = map(lambda x: x[0], web_deliverables)+["index.html"] try: delete_at_end = [] # RPMs first. if options.force or \ (not os.path.exists(binrpm) or not os.path.exists(srcrpm)): print "# Building RPMs..." if newer(srcrpm, tarball) and newer(binrpm, tarball): print "RPMs are up to date" else: do_or_die("buildrpms %s %s" % (tarball, suppress)) delete_at_end.append(srcrpm) delete_at_end.append(binrpm) # Next, the LSM if needed if 'ibiblio' in channels and \ (options.force or not os.path.exists(lsm)): print "# Building LSM..." if keywords: do_or_die("rpm2lsm -k '"+keywords+"' "+binrpm+" >"+lsm) else: print "# Warning: LSM being built with no keywords." do_or_die("rpm2lsm " + binrpm + ">" + lsm) delete_at_end.append(lsm) # Next the index page if it doesn't exist. if homepage and (options.force or not os.path.exists("index.html")): print "# Building index page..." # Now build the resource table resourcetable = '\n' for (file, explanation) in web_deliverables: resourcetable += "\n" % (file,file,explanation) resourcetable += "
%s%s
" # OK, now build the index page itself ofp = open("index.html", "w") ofp.write(indextemplate % globals()) ofp.close() delete_at_end.append("index.html") # Next the CHANGES file. Build this only if (a) there is no ChangeLog, # and (b) there is a specfile %changelog. if not os.path.exists("ChangeLog") and \ (options.force or not os.path.exists("CHANGES")) and changelog: print "# Building CHANGES..." ofp = open("CHANGES", "w") ofp.write(" Changelog for " + package + "\n\n") ofp.write(changelog) ofp.close() delete_at_end.append("CHANGES") # The freshmeat announcement if 'freshmeat' in channels \ and options.force or not os.path.exists("ANNOUNCE.FRESHMEAT"): print "# Building ANNOUNCE.FRESHMEAT..." if not homepage: print "# Can't announce to freshmeat without a primary website!" elif not lastchange: print "# Can't announce to freshmeat without a changes field!" else: while True: focus = raw_input("# freshmeat.net release focus (? for list): ") if focus == '?': i = 0 for f in freshmeat_focus_types: print "%d: %s" % (i, f) i += 1 elif focus in "0123456789": print "# OK:", freshmeat_focus_types[int(focus)] break elif focus.lower() in map(lambda x: x.lower(), freshmeat_focus_types): break else: croak("not a valid freshmeat.net release focus!") ofp = open("ANNOUNCE.FRESHMEAT", "w") ofp.write("Project: %s\n"%(freshmeat_name or package)) ofp.write("Version: %s\n"% version) ofp.write("Release-Focus: %s\n" % focus) ofp.write("Home-Page-URL: %s\n" % homepage) if os.path.exists(tarball): ofp.write("Gzipped-Tar-URL: %s\n" % os.path.join(homepage,tarball)) if os.path.exists(zip): ofp.write("Zipped-Tar-URL: %s\n" % os.path.join(homepage, zip)) if os.path.exists("CHANGES"): ofp.write("Changelog-URL: %s\n" % os.path.join(homepage, "CHANGES")) if os.path.exists(binrpm): ofp.write("RPM-URL: %s\n" % os.path.join(homepage, binrpm)) # freshmeat.net doesn't like bulleted entries. freshmeatlog = lastchange[2:].replace("\n ", "\n") ofp.write("\n" + freshmeatlog) ofp.close() delete_at_end.append("ANNOUNCE.FRESHMEAT") # Finally, email notification if filter(lambda x: x.startswith("mailto:"), destinations) \ and (options.force or not os.path.exists("ANNOUNCE.EMAIL")): print "# Building ANNOUNCE.EMAIL..." ofp = open("ANNOUNCE.EMAIL", "w") ofp.write(mailtemplate % globals()) ofp.close() delete_at_end.append("ANNOUNCE.FRESHMEAT") # # Now actually ship # # Shipping methods, locations, and deliverables for public channels. hardwired = { 'freshmeat' : (lambda: freshmeat_ship(("ANNOUNCE.FRESHMEAT",))), 'ibiblio' : (lambda: upload("ftp://ibiblio.org/incoming/linux", (tarball, binrpm, srcrpm, lsm))), 'redhat' : (lambda: upload("ftp://incoming.redhat.com/libc6", (tarball, binrpm, srcrpm))), } # First ship to private channels. Order is important here, we # need to hit the user's primary website first so everything # will be in place when announcements are generated. for destination in destinations: if destination.startswith("ftp:"): upload(destination, (tarball, binrpm, srcrpm,)) elif destination.startswith("mailto:"): print "# Mailing to %s" % destination command = "sendmail -i -oem -f %s %s